這一集,我們要幫部落格把單篇文章的「內頁」做出來!就像你點進去一篇 PTT 或 Dcard 文,會看到完整內容那樣。還有,我們會學會把「草稿」藏起來,不給別人看,以及弄個帥氣的 404 找不到頁面!
💻 逐步拆解(先看懂骨架!)
A. 資料庫:用代號(Slug)抓「已發佈」文章
文章在資料庫裡都有一個像「獨家代號」的東西,我們叫它 Slug(就是網址上 /posts/ 後面那串英文字)。
我們要寫一個超級專用的抓耙子,它很龜毛,只會抓:
代號要對!
文章狀態要是「已發佈」(published)才行!
// PostRepo 就是資料庫管理員。
// 這個方法只負責抓「已發佈」的文章,草稿(draft)?不給過!
func (r *PostRepo) GetPublishedBySlug(ctx context.Context, slug string) (domain.Post, error) {
// 厲害的 SQL 查詢就在完整版裡!
return domain.Post{}, nil
}
B. 處理器(Handler):文章內容大變身!🎨
我們寫文章用的是 Markdown(像純文字但可以加粗體、列表),但瀏覽器只看得懂 HTML。所以,Handler 要做兩件大事:
拿 Slug 抓文章。
用 goldmark 把 Markdown 變成 HTML。
用 bluemonday 像**「內容安全警衛」一樣,把 HTML 裡危險**的東西(例如偷塞病毒碼)過濾掉!
最後把乾淨的 HTML 塞進模板!
// PostPage 就是部落格內頁的總指揮官。
func (h *FrontPostsHandler) PostPage(c echo.Context) error {
slug := c.Param("slug") // 從網址抓到 Slug 代號!
// 拿代號 → 抓文章 → 轉 Markdown → 過濾 → 塞進模板!
// 記得,找不到或草稿,通通丟 404 錯誤!
return c.Render(http.StatusOK, "pages/post_show.html", data)
}
C. 模板骨架:文章內頁 post_show.html
模板就是文章的外觀設計圖,它會負責把標題、日期和處理好的乾淨文章內容放進去。
{{ define "pages/post_show" -}}
{{ template "layouts/base" . }}
{{ define "title" -}}{{ .Title }}{{ end }} {{ define "content" -}}
{{- end }}
{{- end }}
D. 404 頁與錯誤處理:走丟了別怕!🧭
當使用者來亂逛、輸入一個不存在的網址,或者想偷看你的「草稿」時,我們不能讓網頁當掉!要優雅地告訴他:「抱歉,這頁面走丟了。」
D. 404 頁與錯誤處理:走丟了別怕!🧭
當使用者來亂逛、輸入一個不存在的網址,或者想偷看你的「草稿」時,我們不能讓網頁當掉!要優雅地告訴他:「抱歉,這頁面走丟了。」
E. 路由(Route):設定交通規則 🚦
最後,要告訴伺服器交通規則:看到 /posts/後面一串字 這種網址,就要交給 front.PostPage 這個總指揮官去處理!
e.GET("/posts/:slug", front.PostPage) // :slug 就是那個文章的獨家代號
🛠️ 合併完整內容(直接貼上就能跑!)
下面是把骨架填滿肉的程式碼,你可以直接複製貼到對應檔案裡!
在 internal/storage/postgres/posts.go 裡,關鍵就在這行 WHERE slug = $1 AND status = 'published' LIMIT 1:
// 略...
func (r *PostRepo) GetPublishedBySlug(ctx context.Context, slug string) (domain.Post, error) {
var p domain.Post
// ⬇️ 這裡!只抓 slug 對,**而且** 狀態是 published 的文章!
err := r.Pool.QueryRow(ctx, `
SELECT id, author_id, title, slug, summary, content_md, cover_image, status, published_at, created_at, updated_at
FROM posts
WHERE slug = $1 AND status = 'published'
LIMIT 1
`, slug).Scan(
&p.ID, &p.AuthorID, &p.Title, &p.Slug, &p.Summary, &p.ContentMD,
&p.CoverImage, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
)
// 略... 找不到就回傳 ErrNotFound
if errors.Is(err, pgx.ErrNoRows) {
return p, ErrNotFound
}
return p, err
}
在 internal/http/handlers/front_posts.go 裡,這段是整個部落格內頁的靈魂!
// 略...
// ⭐️ 全域變數:只跑一次,設定好我們的 Markdown 轉換器與安全警衛!
var (
mdParser = goldmark.New() // Markdown 翻譯機
// UGCPolicy:安全警衛,專門過濾使用者發表的內容,把危險的標籤和屬性清掉!
sanitizer = bluemonday.UGCPolicy()
)
func (h *FrontPostsHandler) PostPage(c echo.Context) error {
slug := c.Param("slug")
p, err := h.Repo.GetPublishedBySlug(c.Request().Context(), slug)
if err != nil {
// 沒找到文章(包含它只是草稿),就手動丟出 404 錯誤,讓後面的 Error Handler 接手!
return echo.NewHTTPError(http.StatusNotFound, "post not found")
}
// 🎨 內容大變身!
var htmlBuf []byte
// 1. Markdown 轉成 HTML
if err := mdParser.Convert([]byte(p.ContentMD), &htmlBuf); err != nil {
return c.String(http.StatusInternalServerError, "render markdown failed")
}
// 2. 安全警衛過濾!
safe := sanitizer.SanitizeBytes(htmlBuf)
// 3. 告訴 Go 模板:「這段 HTML 已經檢查過,很安全!」
contentHTML := template.HTML(string(safe))
// 略... 準備資料給模板
data := map[string]any{
"Title": p.Title,
// ... 略
"Post": p,
"ContentHTML": contentHTML, // 塞入乾淨的 HTML 內容
}
return c.Render(http.StatusOK, "pages/post_show.html", data)
}
在 web/templates/pages/post_show.html,我們用 {{ .ContentHTML }} 顯示文章內容:
{{ define "pages/post_show" -}}
{{ template "layouts/base" . }}
{{ define "title" -}}{{ .Title }}{{ end }}
{{ define "content" -}}
<article class="prose max-w-none">
<header class="mb-6">
<h1 class="text-3xl font-bold mb-1">{{ .Title }}</h1> <div class="text-slate-500 text-sm">發佈:{{ .PublishedAt }}</div> </header>
<div class="prose prose-slate">
{{ .ContentHTML }}
</div>
<footer class="mt-8">
<a href="/" class="inline-block rounded border bg-white px-3 py-1 hover:bg-slate-50">← 回首頁</a>
</footer>
</article>
{{- end }}
{{- end }}
在 web/templates/pages/404.html,設計一個超有禮貌的走丟頁面:
{{ define "pages/404" -}}
{{ template "layouts/base" . }}
{{ define "title" -}}頁面不存在{{ end }}
{{ define "content" -}}
<section class="grid place-items-center text-center py-16">
<div class="grid gap-3">
<div class="text-7xl font-bold text-slate-300">404</div>
<h1 class="text-2xl font-semibold">這頁面走丟了 😢</h1>
<p class="text-slate-600">可能文章是草稿、被刪除,或你輸入的網址有誤。</p>
<a href="/" class="mx-auto inline-block rounded border bg白 px-3 py-1 hover:bg-slate-50">回首頁</a>
</div>
</section>
{{- end }}
{{- end }}
這是讓 404 模板能動起來的關鍵設定:
// 略...
// 自訂 404:如果是 404 錯誤,就改用我們的模板頁!
e.HTTPErrorHandler = func(err error, c echo.Context) {
var he *echo.HTTPError
// 檢查是不是 Echo 丟出的 404 錯誤
if errors.As(err, &he) && he.Code == http.StatusNotFound {
data := map[string]any{
"Title": "頁面不存在",
"SiteName": site,
// ... 略
}
// 如果是 API 呼叫(例如用 curl),就回傳 JSON 格式
if strings.Contains(c.Request().Header.Get("Accept"), "application/json") {
_ = c.JSON(http.StatusNotFound, echo.Map{"error": "not found"})
return
}
// 🌟 一般網頁瀏覽,就渲染我們的 404 模板!
_ = c.Render(http.StatusNotFound, "pages/404.html", data)
return
}
// 其他非 404 錯誤,就交給 Echo 預設的處理方式
e.DefaultHTTPErrorHandler(err, c)
}
啟動你的 Go 程式後,可以試試看這些指令:
# 1. 建立一篇 **已發佈** 文章
curl -sX POST http://localhost:1323/api/admin/posts \
-H "Content-Type: application/json" \
-d '{
"author_id": 1,
"title": "公測文一號",
"slug": "public-1",
"summary": "這是公開測試文章",
"content_md": "# Hello\n內文 **Markdown** 測試",
"status": "published"
}' | jq .
# 2. 建立一篇 **草稿** (只有自己看得到!)
curl -sX POST http://localhost:1323/api/admin/posts \
-H "Content-Type: application/json" \
-d '{
"author_id": 1,
"title": "秘密草稿",
"slug": "draft-1",
"content_md": "還在寫...",
"status": "draft"
}' | jq .
# 3. 測試 **已發佈** 內頁 → 應該回傳 200 OK!
curl -i http://localhost:1323/posts/public-1 | head -n 15
# 4. 測試 **草稿** 內頁 → 應該回傳 404 找不到!
curl -i http://localhost:1323/posts/draft-1 | head -n 15
# 5. 測試 **不存在** 的文章 → 當然也是 404!
curl -i http://localhost:1323/posts/not-exist | head -n 15
💥 常見坑(排雷小幫手)🧯
忘了裝套件 → Markdown 轉換失敗
→ 跑 go get + go mod tidy。
直接把 Markdown 的 HTML 丟進模板 → 有風險
→ 一定要先用 bluemonday.UGCPolicy() 過濾,再 template.HTML。
404 沒出現自訂頁面
→ 確認 e.HTTPErrorHandler 有換成你的版本。
strings 未 import
→ 在 404 handler 用到了 strings.Contains,記得 import "strings"。
時區顯示怪怪的
→ 統一用台北時區 Asia/Taipei (+08:00)。
📝 小結
這集我們讓部落格變得更像一個真正的網站了!
現在,點進文章會看到單獨的內頁,網址是 /posts/文章代號。
草稿會被好好保護,別人偷看會看到 404 頁!
文章內容經過 Markdown → HTML → 安全檢查,超安心!
下一步預告:我們已經有文章了,但誰都能亂發文嗎?當然不行!第 7 集,我們要來做 Session 登入,把後台用密碼鎖起來,這樣就只有你能發文和預覽草稿囉!敬請期待!💪